# frozen_string_literal: true

# rubocop:disable RSpec/MultipleMemoizedHelpers

require 'spec_helper'

RSpec.describe Gitlab::BackgroundMigration::CreateVulnerabilityLinks,
  feature_category: :vulnerability_management do
  let(:feedback_types) do
    {
      dismissal: 0,
      issue: 1,
      merge_request: 2
    }
  end

  let!(:issue_base_type_enum) { 0 }
  let!(:issue_type_id) { table(:work_item_types).find_by(base_type: issue_base_type_enum).id }

  let(:namespaces) { table(:namespaces) }
  let(:projects) { table(:projects) }
  let(:users) { table(:users) }
  let(:issues) { table(:issues) }
  let(:issue_links) { table(:vulnerability_issue_links) }
  let(:merge_requests) { table(:merge_requests) }
  let(:merge_request_links) { table(:vulnerability_merge_request_links) }
  let(:vulnerability_identifiers) { table(:vulnerability_identifiers) }
  let(:vulnerability_scanners) { table(:vulnerability_scanners) }
  let(:vulnerability_findings) { table(:vulnerability_occurrences) }
  let(:vulnerabilities) { table(:vulnerabilities) }
  let(:vulnerability_feedback) { table(:vulnerability_feedback) }
  let(:security_scans) { table(:security_scans) }
  let(:security_findings) { table(:security_findings) }
  let(:ci_builds) { table(:ci_builds, database: :ci) { |model| model.primary_key = :id } }
  let(:ci_pipelines) { table(:ci_pipelines, database: :ci) }
  let(:ci_job_artifacts) { table(:ci_job_artifacts, database: :ci) }

  let!(:namespace) { namespaces.create!(name: "test-1", path: "test-1", owner_id: user.id) }

  let(:issue) { create_issue(issue_type_id) }
  let(:merge_request) { create_merge_request }
  let!(:pipeline) { create_ci_pipeline }

  let(:vulnerability_id) { nil }
  let(:finding) { create_finding(scanner, vulnerability_id: vulnerability_id) }

  let(:migration_attrs) do
    {
      start_id: vulnerability_feedback.minimum(:id),
      end_id: vulnerability_feedback.maximum(:id),
      batch_table: :vulnerability_feedback,
      batch_column: :id,
      sub_batch_size: 100,
      pause_ms: 0,
      connection: ApplicationRecord.connection
    }
  end

  let(:commit) { instance_double(Commit, id: "mock sha") }

  let(:sast_category) { 0 }
  let(:sast_scan_type) { 1 }
  let(:nonexistent_project_fingerprint) { SecureRandom.hex(20) }

  # this UUID would be calculcated from gl-sast-report-with-signatures-and-flags.json fixture
  let(:known_uuid) { "429005aa-8b32-58a9-b2ea-bc8ae80b0963" }
  let(:ci_pipeline) { create_ci_pipeline }
  let(:ci_build) do
    create_ci_build(
      project_id: project.id,
      status: "success",
      commit_id: ci_pipeline.id
    )
  end

  # rubocop:disable RSpec/FactoriesInMigrationSpecs
  # I'm not sure how to properly handle this since the path is somehow calculated
  # like tmp/tests/artifacts/5f/9c/5f9c4ab08cac7457e9111a30e4664920607ea2c115a1433d7be98e97e64244ca/2022_09_20
  # /21/21/gl-sast-report-with-signatures-and-flags.json
  let(:ee_ci_job_artifact) do
    create(:ee_ci_job_artifact, :sast_with_signatures_and_vulnerability_flags, job_id: ci_build.id)
  end
  # rubocop:enable RSpec/FactoriesInMigrationSpecs

  let(:security_scan) { create_security_scan(ci_build, sast_scan_type, project_id: project.id) }
  let(:security_finding) { create_security_finding(security_scan, scanner, uuid: known_uuid) }

  before do
    allow(project).to receive(:commit).and_return(commit)
  end

  shared_examples 'a migration creating a vulnerability issue link' do
    it 'creates a Vulnerabilities::IssueLink from the Vulnerabilities::Feedback' do
      subject

      issue_link = issue_links.last
      expect(issue_link.issue_id).to eq(issue.id)
      expect(Vulnerability.find(issue_link.vulnerability_id).findings.pluck(:id)).to include(finding.id)
    end
  end

  shared_examples 'a migration creating a vulnerability merge request link' do
    it 'creates a Vulnerabilities::MergeRequestLink from the Vulnerabilities::Feedback' do
      subject

      merge_request_link = merge_request_links.last
      expect(merge_request_link.merge_request_id).to eq(merge_request.id)
      expect(Vulnerability.find(merge_request_link.vulnerability_id).findings.pluck(:id)).to include(finding.id)
    end
  end

  shared_examples "when there was a problem saving the Vulnerability" do
    let(:errors_like_object) do
      instance_double("ActiveModel::Errors", any?: true, full_messages: ["Title can't be blank"])
    end

    let(:problematic_vulnerability) { instance_double("Vulnerability", valid?: false, errors: errors_like_object) }

    before do
      # https://docs.gitlab.com/ee/development/database/batched_background_migrations.html#isolation
      # forbids using application code in background migrations but we have an exception for this
      # in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/97699#note_1102465241
      allow_next_instance_of(::Vulnerabilities::CreateService) do |service|
        allow(service).to receive(:execute).and_return(problematic_vulnerability)
      end
    end

    it "doesn't create a Vulnerability record" do
      expect { subject }.to change { vulnerabilities.count }.by(0)
    end

    it "logs an error" do
      params = {
        class: "CreateVulnerabilityLinks",
        errors: "Title can't be blank",
        message: "Failed to create Vulnerability"
      }

      expect(::Gitlab::AppLogger).to receive(:error).once.with(params)

      subject
    end
  end

  shared_examples "when creating any associated record fails" do
    let(:error_response) { instance_double(ServiceResponse, message: "an error", error?: true) }

    before do
      # https://docs.gitlab.com/ee/development/database/batched_background_migrations.html#isolation
      # forbids using application code in background migrations but we have an exception for this
      # in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/97699#note_1102465241
      allow_next_instance_of(::Vulnerabilities::FindOrCreateFromSecurityFindingService) do |service|
        allow(service).to receive(:execute).and_return(error_response)
      end
    end

    it "doesn't create a Vulnerability record" do
      expect { subject }.to change { vulnerabilities.count }.by(0)
    end

    it "logs an error" do
      params = {
        message: "Failed to create Vulnerability from Security::Finding",
        class: "CreateVulnerabilityLinks",
        error: "an error"
      }

      expect(::Gitlab::AppLogger).to receive(:error).once.with(params)

      subject
    end
  end

  shared_examples "when the link is invalid" do |link_type|
    let(:klass) { "EE::#{described_class}::#{link_type}Link".constantize }

    let(:errors_like_object) do
      instance_double("ActiveModel::Errors", any?: true, full_messages: ["An error"])
    end

    let(:invalid_link) do
      instance_double(klass.to_s, save: false, errors: errors_like_object)
    end

    before do
      allow(klass).to receive(:new).and_return(invalid_link)
    end

    it "doesn't create a #{link_type}Link" do
      expect { subject }.not_to change { send("#{link_type.underscore}_links").count }
    end

    it "logs an error" do
      params = {
        class: "CreateVulnerabilityLinks",
        errors: "An error",
        message: "Failed to create a #{link_type.titleize} Link",
        vulnerability_id: kind_of(Integer),
        feedback_id: kind_of(Integer)
      }

      expect(::Gitlab::AppLogger).to receive(:error).once.with(params)

      subject
    end
  end

  describe "#perform", feature_category: :vulnerability_management do
    before do
      stub_licensed_features(security_dashboard: true)
    end

    let!(:scanner) { create_scanner }

    subject { described_class.new(**migration_attrs).perform }

    context "for vulnerability feedback issues" do
      let(:feedback_type) { feedback_types[:issue] }

      context "when a Finding has no Vulnerability" do
        let!(:feedback) do
          create_feedback(
            finding.report_type,
            feedback_type,
            finding.project_fingerprint,
            finding.uuid,
            comment: "this feedback is for a Vulnerabilities::Finding",
            issue_id: issue.id
          )
        end

        it_behaves_like 'when there was a problem saving the Vulnerability'

        context "when the feedback is associated with a non-existent issue" do
          let!(:feedback) do
            create_feedback(
              finding.report_type,
              feedback_type,
              finding.project_fingerprint,
              finding.uuid,
              comment: "this feedback is for a Vulnerabilities::Finding",
              issue_id: nil
            )
          end

          it "doesn't create a Vulnerability record" do
            expect { subject }.to change { vulnerabilities.count }.by(0)
          end
        end

        it 'creates a Vulnerability from the Vulnerabilities::Finding' do
          expect { subject }.to change { vulnerabilities.count }.by(1)
        end

        it_behaves_like 'a migration creating a vulnerability issue link'
      end

      context "when there's only a Security::Finding" do
        let!(:feedback) do
          # Make sure these are initialized first
          ee_ci_job_artifact
          security_finding

          create_feedback(
            sast_category,
            feedback_type,
            nonexistent_project_fingerprint,
            known_uuid,
            comment: "this feedback is for a Security::Finding",
            issue_id: issue.id
          )
        end

        it_behaves_like "when creating any associated record fails"

        it 'creates a Vulnerability from the Security::Finding' do
          expect { subject }.to change { vulnerabilities.count }.by(1)
        end

        it 'creates a Vulnerabilities::IssueLink from the Vulnerabilities::Feedback' do
          subject

          issue_link = issue_links.last
          expect(issue_link.issue_id).to eq(issue.id)
          expect(Vulnerability.find(issue_link.vulnerability_id).findings.pluck(:uuid)).to include(known_uuid)
        end
      end

      context "when there is a vulnerability" do
        let(:vulnerability) { create_vulnerability }
        let(:vulnerability_id) { vulnerability.id }
        let!(:feedback) do
          create_feedback(
            finding.report_type,
            feedback_type,
            finding.project_fingerprint,
            finding.uuid,
            issue_id: issue.id
          )
        end

        it_behaves_like "when the link is invalid", "Issue"

        it_behaves_like 'a migration creating a vulnerability issue link'
      end
    end

    context 'for vulnerability feedback merge requests' do
      let(:feedback_type) { feedback_types[:merge_request] }

      context "when a Finding has no Vulnerability" do
        let!(:feedback) do
          create_feedback(
            finding.report_type,
            feedback_type,
            finding.project_fingerprint,
            finding.uuid,
            comment: "this feedback is for a Vulnerabilities::Finding",
            merge_request_id: merge_request.id
          )
        end

        it_behaves_like 'when there was a problem saving the Vulnerability'

        context "when the feedback is associated with a non-existent merge request" do
          let!(:feedback) do
            create_feedback(
              finding.report_type,
              feedback_type,
              finding.project_fingerprint,
              finding.uuid,
              comment: "this feedback is for a Vulnerabilities::Finding",
              merge_request_id: nil
            )
          end

          it "doesn't create a Vulnerability record" do
            expect { subject }.to change { vulnerabilities.count }.by(0)
          end
        end

        it 'creates a Vulnerability from the Vulnerabilities::Finding' do
          expect { subject }.to change { vulnerabilities.count }.by(1)
        end

        it_behaves_like 'a migration creating a vulnerability merge request link'
      end

      context "when there's only a Security::Finding" do
        let!(:feedback) do
          # Make sure these are initialized first
          ee_ci_job_artifact
          security_finding

          create_feedback(
            sast_category,
            feedback_type,
            nonexistent_project_fingerprint,
            known_uuid,
            comment: "this feedback is for a Security::Finding",
            merge_request_id: merge_request.id
          )
        end

        it_behaves_like "when creating any associated record fails"

        it 'creates a Vulnerability from the Security::Finding' do
          expect { subject }.to change { vulnerabilities.count }.by(1)
        end

        it 'creates a Vulnerabilities::MergeRequestLink from the Vulnerabilities::Feedback' do
          subject

          merge_request_link = merge_request_links.last
          expect(merge_request_link.merge_request_id).to eq(merge_request.id)
          expect(Vulnerability.find(merge_request_link.vulnerability_id).findings.pluck(:uuid)).to include(known_uuid)
        end
      end

      context "when there is a vulnerability" do
        let(:vulnerability) { create_vulnerability }
        let(:vulnerability_id) { vulnerability.id }
        let!(:feedback) do
          create_feedback(
            finding.report_type,
            feedback_type,
            finding.project_fingerprint,
            finding.uuid,
            merge_request_id: merge_request.id
          )
        end

        it_behaves_like "when the link is invalid", "MergeRequest"

        it_behaves_like 'a migration creating a vulnerability merge request link'
      end
    end
  end

  private

  def project
    @project ||= projects.create!(id: 9999, namespace_id: namespace.id, project_namespace_id: namespace.id,
      creator_id: user.id)
  end

  def user
    @user ||= create_user(email: "test1@example.com", username: "test1")
  end

  def create_security_scan(build, scan_type, _overrides = {})
    attrs = {
      build_id: build.id,
      scan_type: scan_type
    }

    security_scans.create!(attrs)
  end

  def create_ci_build(overrides = {})
    attrs = {
      type: 'Ci::Build',
      partition_id: 100
    }.merge(overrides)
    ci_builds.create!(attrs)
  end

  def create_security_finding(security_scan, scanner, overrides = {})
    attrs = {
      scan_id: security_scan.id,
      scanner_id: scanner.id,
      severity: 2 # unknown
    }.merge(overrides)

    security_findings.create!(attrs)
  end

  def create_scanner(overrides = {})
    attrs = {
      project_id: project.id,
      external_id: "test_vulnerability_scanner",
      name: "Test Vulnerabilities::Scanner"
    }.merge(overrides)

    vulnerability_scanners.create!(attrs)
  end

  def create_finding(scanner, overrides = {})
    attrs = {
      project_id: project.id,
      scanner_id: scanner.id,
      severity: 5, # medium
      confidence: 2, # unknown,
      report_type: 99, # generic
      primary_identifier_id: create_identifier.id,
      project_fingerprint: SecureRandom.hex(20),
      location_fingerprint: SecureRandom.hex(20),
      uuid: SecureRandom.uuid,
      name: "CVE-2018-1234",
      raw_metadata: "{}",
      metadata_version: "test:1.0"
    }.merge(overrides)

    vulnerability_findings.create!(attrs)
  end

  def create_identifier(overrides = {})
    attrs = {
      project_id: project.id,
      external_id: "CVE-2018-1234",
      external_type: "CVE",
      name: "CVE-2018-1234",
      fingerprint: SecureRandom.hex(20)
    }.merge(overrides)

    vulnerability_identifiers.create!(attrs)
  end

  def create_vulnerability(overrides = {})
    attrs = {
      title: "test",
      severity: 6, # high
      confidence: 6, # high
      report_type: 0, # sast
      description: "test",
      project_id: project.id,
      author_id: overrides.fetch(:author_id) { user.id }
    }

    vulnerabilities.create!(attrs)
  end

  def create_feedback(category, feedback_type, project_fingerprint, finding_uuid, overrides = {})
    attrs = {
      project_fingerprint: project_fingerprint,
      category: category,
      project_id: project.id,
      author_id: user.id,
      feedback_type: feedback_type,
      finding_uuid: finding_uuid
    }.merge(overrides)

    vulnerability_feedback.create!(attrs)
  end

  def create_issue(issue_type_id, overrides = {})
    attrs = {
      project_id: project.id,
      author_id: user.id,
      work_item_type_id: issue_type_id,
      namespace_id: project.project_namespace_id,
      title: 'Feedback Issue'
    }.merge(overrides)

    issues.create!(attrs)
  end

  def create_merge_request(overrides = {})
    attrs = {
      target_project_id: project.id,
      source_branch: "other",
      target_branch: "main",
      author_id: user.id,
      title: 'Feedback Merge Request'
    }.merge(overrides)

    merge_requests.create!(attrs)
  end

  def create_ci_pipeline(overrides = {})
    attrs = {
      project_id: project.id,
      user_id: user.id,
      ref: 'refs/heads/master',
      sha: "awegw3t3223",
      before_sha: '00000000',
      status: :success,
      partition_id: 100
    }.merge(overrides)

    ci_pipelines.create!(attrs)
  end

  def create_user(overrides = {})
    attrs = {
      email: "test@example.com",
      notification_email: "test@example.com",
      name: "test",
      username: "test",
      state: "active",
      projects_limit: 10
    }.merge(overrides)

    users.create!(attrs)
  end
end

# rubocop:enable RSpec/MultipleMemoizedHelpers
